import co from 'co';
import util from 'util';
import { Server as WebSocketServer } from 'ws';
import md5 from 'md5';

/**
 * KoaWebSocketServer
 */
class KoaWebSocketServer {
    constructor(app, router, opts) {
        const DEFAULTS = {
            onOpen: null,
            onClose: null,
            onError: null,
        };

        this.options = Object.assign({}, DEFAULTS, opts);

        this.app = app;

        this.log = this.app.log.getLogger('ws');

        this.router = router;

        this.methods = {};

        this.clients = {};

        // register all routes
        this.register();
    }

    listen(server, done) {
        // create WebSocketServer
        this.server = new WebSocketServer({
            server,
        });

        // listen to connection
        this.server.on('connection', this.onConnection.bind(this));

        if (done) {
            done.call(null);
        }
    }

    onConnection(ws) {
        const self = this;
        const client = {};

        // create client id
        client.id = this.genId(11);

        // check protocol
        if (!this.checkProtoal(ws.protocol)) {
            this.log.error('check protocol is failed: %s', ws.protocol);
            ws.close(1001, '12312345678');
            return {};
        }

        // create heartbeat watcher
        client.heartbeat = {
            lastTime: 0,
            watcher: null,
        };

        if (this.options.heartbeat) {
            client.heartbeat.watcher = setInterval(
                this.onHeartbeat.bind(this, ws),
                this.options.heartbeatInterval);
        }

        // inject methods
        client.updateHeartbeatTime = () => {
            client.heartbeat.lastTime = self.getNowMs();
        };

        client.heartbeat = () => {
            const sendData = {
                method: 'heartbeat',
                message: 'ticktack',
            };

            const serializeData = self.serialize(sendData);
            if (serializeData && serializeData !== '') {
                try {
                    ws.send(serializeData);
                } catch (error) {
                    clearInterval(client.heartbeat.watcher);
                    self.log.error('[kws] heartbeat send failed %s', error);
                }

                // update heartbeat ts
                ws.updateHeartbeatTime();
                // send log
                self.log.debug('[kws] send %s', serializeData);
            }
        };

        client.call = (method, data, context = self.throwIfMissing('context')) => {
            if (!method || !data) return;

            let sendData = {
                method,
                id: context.id,
                sid: context.sid,
                cid: context.cid,
                status: context.status,
            };

            if (util.isString(data)) {
                sendData.message = data;
            } else {
                sendData = Object.assign({}, data, sendData);
            }

            const serializeData = self.serialize(sendData);
            if (serializeData && serializeData !== '') {
                try {
                    ws.send(serializeData);
                } catch (error) {
                    self.log.error('[kws] send failed %s', error);
                }
                // update heartbeat ts
                ws.updateHeartbeatTime();
                // send log
                self.log.info('[kws] send %s', serializeData);
            }
        };

        // extend client
        Object.assign(ws, client);

        // register handlers
        ws.on('open', this.onOpen.bind(this, ws));
        ws.on('close', this.onClose.bind(this, ws));
        ws.on('error', this.onError.bind(this, ws));
        ws.on('message', this.onMessage.bind(this, ws));

        // keep client in map
        this.clients[ws.id] = ws;

        return {};
    }

    onHeartbeat(client) {
        const now = this.getNowMs();
        this.log.debug('[kws] client %s heartbeat', client.id);
        if ((now - client.heartbeat.lastTime) > (this.options.heartbeatInterval)) {
            client.heartbeat();
        }
    }

    onOpen(client) {
        this.log.info('[kws] client %s is open', client.id);
        if (this.options.onOpen) {
            this.options.onOpen(client);
        }
    }

    onClose(client) {
        this.log.info('[kws] client %s is close', client.id);
        if (client.heartbeat.watcher) {
            clearInterval(client.heartbeat.watcher);
        }
        if (this.options.onClose) {
            this.options.onClose(client);
        }
    }

    onError(client, error) {
        this.log.error('[kws] client %s error: %s', client.id, error);
        if (this.options.onError) {
            this.options.onError(client);
        }
    }

    onMessage(client, message) {
        const self = this;
        this.log.info('[kws] client %s received message: %s', client.id, message);

        // update heartbeat ts
        client.updateHeartbeatTime();

        const params = this.unserialize(message);
        if (!params || !('method' in params) || !('cid' in params)) return;

        // build context
        const context = {
            id: client.id,
            sid: this.genId(),
            cid: params.cid,
            wss: this.server,
            app: this.app,
            params,
            status: 0,
            body: null,
        };

        // bind functions
        context.send = this.send.bind(this, context);
        context.broadcast = this.broadcast.bind(this, context);
        context.exbroadcast = this.exbroadcast.bind(this, context);
        context.close = this.close.bind(this, context);

        // open save cid <==> client
        if (params.method === 'dc:open') {
            this.onOpen(context);
        }

        // find route method
        if (params.method in this.methods) {
            const method = this.methods[params.method];

            // call route method
            method.call(context)
                .then(() => {
                    const callCtx = Object.assign({}, context);
                    client.call(params.method, callCtx.body, callCtx);
                })
                .catch((e) => {
                    self.log.error('[kws] client %s exception: %s', client.id, e);
                    // send error message
                    const err = {
                        error: true,
                        body: e.message,
                        other: e.other,
                    };
                    const callCtx = Object.assign({}, context, err);
                    client.call(params.method, callCtx.body, callCtx);
                });
        }
    }

    register() {
        const routes = this.router.routes();

        for (const [method, gen] of Object.entries(routes)) {
            this.methods[method] = co.wrap(gen);
        }
    }

    send(/* skip */context, method, data, id) {
        const clientId = id || context.id;
        if (clientId in this.clients) {
            this.clients[clientId].call(method, data, context);
        }
    }

    broadcast(/* skip */context, method, data) {
        this.server.clients.forEach((client) => {
            client.call(method, data, context);
        });
    }

    exbroadcast(/* skip */context, method, data) {
        this.server.clients.forEach((client) => {
            if (client.id === context.id) return;
            client.call(method, data, context);
        });
    }

    close(/* skip */context, code, msg) {
        this.clients[context.id].close(code, msg);
    }

    genId(len = 20) {
        return Math.random().toString(36).substr(2, len);
    }

    getNowMs() {
        return (+new Date());
    }

    checkProtoal(protocol) {
        const tokenKey = '17nX3P';

        this.log.info('[kws] protocol: %s', protocol);

        if (protocol) {
            const arry = protocol.split('_');

            if (arry.length !== 3) {
                return false;
            }

            const deviceId = arry[0];
            const timestamp = arry[1];
            const token = arry[2];

            const digestToken = md5(deviceId + timestamp + tokenKey);

            this.log.info('[kws] deviceId: %s timestamp: %s, token: %s, digestToken: %s',
                deviceId, timestamp, token, digestToken);

            return token === digestToken;
        }
        return false;
    }

    serialize(obj) {
        let ret = '';
        try {
            ret = JSON.stringify(obj);
        } catch (e) {
            this.log.error('[kws] serialize error: %s, obj: %s', e, obj);
        }
        return ret;
    }

    unserialize(json) {
        let ret = null;
        try {
            ret = JSON.parse(json);
        } catch (e) {
            this.log.error('[kws] unserialize error: %s, json: %s', e, json);
        }
        return ret;
    }

    throwIfMissing(name) {
        throw new Error(`missing param ${name}`);
    }
}

export default KoaWebSocketServer;
